2. MyBatis 特训指南(极简源码速成版)
本指南专为快速吃透 苍穹外卖基于 MyBatis 的高效数据访问层设计、基于 ThreadLocal + 拦截器 的公共字段自动填充 以及 高并发数据库查询性能优化 等核心场景中的 MyBatis 考点而设计。杜绝大段铺垫,采用 Why - What - How - Deep 四步直达底层源码与 AOP 字节码,帮助您在面试中反客为主,化被动为主动。
🚀 核心概念极简拆解
- 预编译 SQL (Prepared SQL)
- Why:数据库每次执行新的 SQL 都必须进行高昂的词法语法分析、优化并生成执行计划。如果相同的 SQL 仅因为参数不同就重复编译,会白白耗尽数据库 CPU 算力。
- What/How:在执行前先将 SQL 结构发给数据库编译,参数位置以占位符
?代替。编译好后,后续请求只需传入纯参数数据即可直接执行,极大提升执行效率,且能彻底剿灭 SQL 注入。
- 动态代理 MapperProxy
- Why:传统的 JDBC 必须手动写大量的
SqlSession.selectOne("UserMapper.selectById", id)强转样板代码,无法像调用普通 Java 接口方法那样干净解耦。 - What/Deep:MyBatis 核心魔术。开发只需定义 Mapper 接口,MyBatis 启动时利用 JDK 动态代理 在内存中为该接口生成一个
MapperProxy代理实例 并注入 Spring 容器,使接口方法调用无感转化为底层的 SQL 执行。
- Why:传统的 JDBC 必须手动写大量的
- 一级缓存 (Local Cache)
- Why:在同一个数据库会话(SqlSession)内,多次执行一模一样的 SQL 会产生多余的数据库网络 I/O 交互。
- What/Deep:默认开启的 SqlSession 级别 缓存。底层由一个简单的
PerpetualCache(内部封装了一个普通的HashMap)实现。
- 二级缓存 (Second Level Cache)
- Why:一级缓存生命周期太短(随会话消亡或更新清空)。需要一种能跨会话、在 Namespace 级别共享的高生命周期缓存。
- What/Deep:Namespace(Mapper 接口)级别 的缓存。由于存在严重的多表 Join 更新脏读灾难,在真实的互联网分布式项目中,强烈建议彻底禁用并关闭它,改用业务层分布式多级缓存方案。
- 延迟加载 (Lazy Loading)
- Why:进行多表关联嵌套查询(如订单关联查询用户)时,如果只访问订单的基本属性,就不应该无脑连表查询用户表,以节省昂贵的联表网络与内存 I/O。
- What/Deep:基于 CGLIB(或 Javassist)动态代理 机制。为实体类在内存中生成代理子类,当业务调用被延迟属性的
getter时,触发方法拦截器向数据库秘密发送第二条关联 SQL 并装配返回。
🚀 MyBatis 核心执行与插件拦截骨架
在面试中谈到 MyBatis 插件和底层原理,您可以通过下图快速呈现 MyBatis 从 SqlSession 开始,依次通过四大核心对象执行 SQL 并完成结果映射的流转链路,以及自定义拦截器(Plugins)在这些核心对象上的 AOP 织入切入点:
mermaid
graph TD
classDef core fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px;
classDef intercept fill:#fff3e0,stroke:#ffb74d,stroke-width:1px;
SqlSession[SqlSession 会话接口] --> Executor[1. Executor 执行器 <br> 调度事务 / 缓存管理]:::core
Executor --> Intercept1[插件拦截点 1]:::intercept
Intercept1 --> StatementHandler[2. StatementHandler 语句处理器 <br> JDBC SQL 预编译与执行]:::core
StatementHandler --> Intercept2[插件拦截点 2]:::intercept
Intercept2 --> ParameterHandler[3. ParameterHandler 参数处理器 <br> 依赖注入占位符参数]:::core
ParameterHandler --> Intercept3[插件拦截点 3]:::intercept
Intercept3 --> ResultSetHandler[4. ResultSetHandler 结果集处理器 <br> 结果映射与 ORM 实体包装]:::core
ResultSetHandler --> Intercept4[插件拦截点 4]:::intercept
Intercept4 --> Client[Java 实体 / 映射结果返回]🎯 第一优先级核心考点详解
一、 #{} 与 ${} 区别及 SQL 注入防御本质 (Why-What-How-Deep)
- Why(为什么
${}无法防止 SQL 注入而#{}可以?)- 痛点:如果数据库在拼接 SQL 参数时采用纯文本硬替换,攻击者传入恶意的 SQL 逻辑片段(如
1' OR '1'='1),会无阻地直接参与编译并强行改写原有的 SQL 语法树语义,直接攻破安全防线。 - 解决:预编译机制强制将参数剥离出语法编译环节。
- 痛点:如果数据库在拼接 SQL 参数时采用纯文本硬替换,攻击者传入恶意的 SQL 逻辑片段(如
- What(占位符与字面量拼接)
#{}:预编译参数占位符。在 SQL 解析时被替换为统一的?,执行时安全填入。${}:字面量拼接符。直接将变量进行静态的 String 字符串硬拼接,存在严重注入隐患。
- How(
${}不得不用的特殊刚需场景)- 当 SQL 内部结构(如表名、排序列名、排序规则)无法使用占位符
?代替时,必须使用${}进行字面量拼接:- 动态指定排序列或排序规则:
ORDER BY ${columnName} ${orderType}(若写成#{}注入后会带上单引号变为ORDER BY 'price'导致 SQL 语法失效报错)。 - 动态分表表名传递:
FROM ${tableName}(表名无法被预编译?代替)。
- 动态指定排序列或排序规则:
- 安全规范:在必须使用
${}的场景下,必须在 Java 业务层执行严格的白名单白过滤,杜绝任意字符串输入。
- 当 SQL 内部结构(如表名、排序列名、排序规则)无法使用占位符
- Deep(深入内核:PreparedStatement 预编译原理与语法树的绝对安全防御)
- PreparedStatement 语法树锚定原理:
- 当 MyBatis 遇到
#{}并使用PreparedStatement进行预编译时,MySQL 会将形如SELECT * FROM users WHERE username = ?的 SQL 语句首先发送至数据库内核。 - 数据库会对该 SQL 进行词法、语法分析并进行编译优化,直接生成并固化好该 SQL 对应的物理抽象语法树(AST)结构。此时,占位符
?作为语法树上的一个孤立叶子节点被锁定。
- 当 MyBatis 遇到
- 参数强转字面量机制:
- 后续当调用
setXXX(1, "' or '1'='1")填入真实参数时,数据库内核会将该传入值强行作为“纯字面量字符串数据”进行数据类型转义。 - 传入的任何特殊控制符号(如单引号、双引号、OR、AND)都会在底层被直接进行物理转义字符处理,使其彻底丧失作为 SQL 指令编译执行的特权。它只能作为 username 的一个具体字符串数值去执行匹配。这种设计从物理结构上百分之百杀死了 SQL 注入的可能。
- 后续当调用
- PreparedStatement 语法树锚定原理:
二、 一级与二级缓存底层源码与脏读灾难 (Why-What-How-Deep)
- Why(为什么要有缓存机制?)
- 痛点:高频执行重复查询,会导致多余的数据库磁盘 I/O 负载。
- 解决:引入一/二级缓存,在会话或 Namespace 级别进行内存共享检索。
- What(一/二级缓存级别差异)
- 一级缓存:SqlSession 会话级别缓存。默认开启,无法关闭。
- 二级缓存:Namespace(Mapper 接口)级别缓存。跨会话共享,默认关闭。
- How(大厂禁用二级缓存的根本致命缺陷:多表 Join 脏读灾难)
- 脏读物理成因:二级缓存是基于 Namespace 级别进行数据隔离的。如果我们在
OrderMapper中写了一条多表 Join 关联查询,关联了User表的数据,并将结果缓存进OrderMapper的二级缓存中。 - 数据错乱触发:此时,并发事务在
UserMapper中对该用户执行了UPDATE修改并提交。因为该修改属于User这个 Namespace,它只会清空UserMapper下的二级缓存,而对OrderMapper下的二级缓存完全一无所知。 - 后果:后续外部通过
OrderMapper再次查询,依然会击中二级缓存中的过期旧脏数据,发生严重的数据不一致脏读,这在工业级高精度场景下是绝对不可原谅的灾难。
- 脏读物理成因:二级缓存是基于 Namespace 级别进行数据隔离的。如果我们在
- Deep(深入源码:一级缓存 PerpeturalCache 的清理源码时机)
- 一级缓存底座:
- 一级缓存由 SqlSession 内部的
BaseExecutor维护。其本质是一个简单的PerpetualCache类,内部仅封装了一个极其普通的非并发安全HashMap:javapublic class PerpetualCache implements Cache { private final String id; private final Map<Object, Object> cache = new HashMap<>(); // 一级缓存底层就是它! ... }
- 一级缓存由 SqlSession 内部的
- 强一致性清理时机源码剖析:
- 为了保障事务内数据强一致,MyBatis 源码中强制在执行任何写动作(
INSERT/UPDATE/DELETE)以及事务提交/回滚时执行缓存清空。我们翻看BaseExecutor.update()源码:java在@Override public int update(MappedStatement ms, Object parameter) throws SQLException { ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId()); if (closed) { throw new ExecutorException("Executor was closed."); } // 👉 核心源码:在执行任何一条 update/insert/delete 之前,无条件强行全部清空一级缓存! clearLocalCache(); return doUpdate(ms, parameter); }clearLocalCache()底层,它会直接调用localCache.clear()(即HashMap.clear())将整个一级缓存清空,彻底防范了事务内连续查询间的脏读隐患。
- 为了保障事务内数据强一致,MyBatis 源码中强制在执行任何写动作(
- 一级缓存底座:
三、 延迟加载 (Lazy Loading) 底层 CGLIB 代理机制 (Why-What-How-Deep)
- Why(为什么说延迟加载对多表关联查询性能极其友好?)
- 痛点:当从数据库中查询出订单表列表时,如果在绝大多数页面我们只需要展示订单的基本价格和时间,就不应该强行去 Join 关联查询耗时极长、体积庞大的用户画像及明细表,这会耗尽内存和网卡 I/O。
- 解决:开启延迟加载,只有在 Java 代码中真正调用
order.getUser()时,才发起第二条 SQL 查询。
- What/How(延迟加载配置)
- 在 application.yml 中,将全局参数
lazyLoadingEnabled设置为true开启。
- 在 application.yml 中,将全局参数
- Deep(深入代理:半成品对象实例化与 getter 触发拦截机制)
- CGLIB 半成品代理子类生成:
- 当 MyBatis 从数据库中读取到订单基础数据,进行结果映射(ORM)装配时,它检测到
user属性配置了延迟加载。 - MyBatis 不会将
user设为普通的 null。它会调用底层的 CGLIB(或 Javassist)字节码操作框架,在 JVM 内存中动态为Order实体类生成一个代理子类对象。 - 在这个代理子类对象中,MyBatis 注入了一个特殊的拦截器 ——
JavassistProxyFactory$EnhancedResultObjectProxyImpl(如果是 CGLIB,则是对应的 MethodInterceptor)。这个半成品订单代理对象被返回给业务层。
- 当 MyBatis 从数据库中读取到订单基础数据,进行结果映射(ORM)装配时,它检测到
getter调用触发无感二级 SQL 注入:- 当我们在 Java 代码中执行普通的属性获取
order.getOrderNo()时,拦截器检测到这一属性不是延迟加载字段,直接放行返回。 - 一旦代码中调用了
order.getUser(),方法拦截器会被瞬间触发拦截动作:- 拦截器检测到
user这个被拦截字段目前依然是未加载的半成品状态。 - 拦截器内部会立即动态拉起原先绑定的数据库执行上下文,“秘密”向数据库发送第二条关联查询 SQL:
SELECT * FROM user WHERE id = ?。 - 查回真实的用户数据后,拦截器通过反射技术,将数据无缝注入填充到当前订单代理对象的
user属性中,最后将填充好的user返回给调用者。
- 拦截器检测到
- 整个过程对外表现为极其流畅的同步调用,底层却实现了精妙的非必要延迟加载。
- 当我们在 Java 代码中执行普通的属性获取
- CGLIB 半成品代理子类生成:
四、 插件拦截器 (Interceptor) 底层 JDK 动态代理多层包装责任链 (Why-What-How-Deep)
- Why(为什么说 MyBatis 拥有极强的插拔式可扩展性?)
- 痛点:如果要在 MyBatis 执行 SQL 的关键步骤(如预编译前、参数注入前、结果集解析后)统一织入非侵入的公共字段填充、分库分表路由、多租户拼接等拦截逻辑,硬编码切面会导致底层逻辑臃肿混乱。
- 解决:提供高度可定制的插件(Interceptor)拦截器,支持对核心流水线对象的精准代理拦截。
- What(四大核心拦截对象)
Executor:调度事务、管理缓存的核心执行器(最上层)。StatementHandler:负责 JDBC Statement 预编译与执行的语句处理器。ParameterHandler:负责为 SQL 占位符?注入 Java 实参的参数处理器。ResultSetHandler:负责将 JDBC ResultSet 映射包装转化为 Java 实体对象的结果集处理器。
- How(自动填充拦截器的黄金实战)
- 简历实战引用:在我们的自动填充拦截器中,正是通过拦截
Executor.class内部的update方法,实现对公共字段的 ThreadLocal 反射注入。👉 点击跳转简历场景一
- 简历实战引用:在我们的自动填充拦截器中,正是通过拦截
- Deep(深入源码:JDK 动态代理多层包装机制与 AOP 责任链底层源码揭秘)
- MyBatis 插件是怎么在底层跑起来的?
- 当我们启动 MyBatis 并通过 XML 注册自定义的
AutoFillInterceptor插件时,系统会将其缓存到全局InterceptorChain(拦截器链)中。 - 在 MyBatis 实例化
Executor或StatementHandler的那一刻,底层会立即调用InterceptorChain.pluginAll(target)方法。我们来翻看其底层核心源码:java在public class InterceptorChain { private final List<Interceptor> interceptors = new ArrayList<>(); public Object pluginAll(Object target) { // 👉 核心源码:遍历所有注册的插件,对目标对象进行多层 JDK 动态代理嵌套包装! for (Interceptor interceptor : interceptors) { target = interceptor.plugin(target); // 包装成代理对象并返回 } return target; // 返回最终的被多层代理包裹的“套娃”对象 } }interceptor.plugin()底层,最终调用的是Plugin.wrap(target, this)。该方法通过Proxy.newProxyInstance(),动态生成一个实现了 MyBatis 核心接口(如 Executor 接口)的动态代理对象。
- 当我们启动 MyBatis 并通过 XML 注册自定义的
- 多级 AOP 责任链的执行机制:
- 当我们调用
executor.update()时,实际上是在调用被多层代理“套娃式”包裹的最高层$Proxy代理对象。 - 由于是代理调用,它会首先触发其绑定的
Plugin.invoke()方法。 - 在
invoke()内部,判定当前执行的方法是否符合我们在@Signature注解中声明的拦截签名:- 符合签名:代理对象会先暂停调用,转而执行我们的
Interceptor.intercept(new Invocation(target, method, args))拦截方法。在执行完我们的自动填充/公共逻辑后,我们通过invocation.proceed()放行,这会使代理链继续进入下一层代理对象的invoke,直到最底层的真实目标对象。 - 不符合签名:直接调用
method.invoke(target, args)放行至下一层。
- 符合签名:代理对象会先暂停调用,转而执行我们的
- 这一设计通过精妙的 JDK 动态代理多层嵌套,在内存中构建起了一条完备的高并发 AOP 责任链,成为了各类高性能分库分表、自动填充插件的工业级设计底座!
- 当我们调用
- MyBatis 插件是怎么在底层跑起来的?
🎯 场景亮点深度关联与对线场景 (Why-What-How)
场景一:公共字段自动填充拦截器与【MyBatis 拦截器 (Interceptor) 四大核心对象】
面试官切入点:
“你在项目中提到了基于 MyBatis 的数据访问层设计。对于很多业务表,插入时需要填充
createTime、createUser,更新时需要填充updateTime、updateUser。如果在每个 Service 里写 set 逻辑太冗余,漏写还会引发非空报错。请问在底层你是如何优雅地进行公共字段自动填充的?MyBatis 拦截器原理是怎样的?”
回答思路 (Why-What-How-Deep 拆解):
- Why:如果在每个 Service 或 Controller 中手工 set 公共审计字段,不仅代码耦合、极其难看,而且容易因为程序员疏忽漏写引发物理非空报错。必须通过非侵入的底层拦截机制实现 100% 自动注入填充。
- What/How: 我们自研了 自定义注解
@AutoFill+ MyBatis 拦截器 (Interceptor) 方案。 - Deep(拦截器自动填充源码级落地):
- 声明拦截切入点:我们自定义
AutoFillInterceptor类,利用 MyBatis 的@Intercepts声明对Executor.class的update方法(该方法包含了所有的物理INSERT和UPDATE)进行代理拦截。 - 写操作判定与实体提取:当写操作触发时,代理对象拦截调用。我们通过
MappedStatement提取出当前的 SQL 运行期指令,通过mappedStatement.getSqlCommandType()精准判定当前是SqlCommandType.INSERT还是UPDATE。 - ThreadLocal 画像提取与反射注入:
- 我们通过拦截方法参数获取当前被执行的数据库实体参数对象
entity。 - 利用反射扫描
entity是否被标注了自定义的@AutoFill注解。若有,则通过反射提取实体中所有的setter方法名。 - 利用我们封装的
BaseContext(底层基于 ThreadLocal 强隔离机制)极速读取当前正在操作该接口的登录用户 ID,并提取当前系统时间戳。 - 利用反射在内存中直接强行调用
setCreateTime、setCreateUser或setUpdateTime、setUpdateUser为实体属性赋值。 - 最后调用
invocation.proceed()放行代理责任链,MyBatis 就会拿着已被我们完美注入好公共字段的实体,继续往下执行原生的预编译 SQL 执行与落库。这彻底实现了业务层零感知、数据库强约束的优雅自动填充,展现了极强的工业级工程素养!
- 我们通过拦截方法参数获取当前被执行的数据库实体参数对象
- 声明拦截切入点:我们自定义
📝 第三优先级:避坑与实战常识
- SqlSession 线程不安全本质与 SqlSessionTemplate 底层 ThreadLocal 避坑
- 成因剖析:MyBatis 的
DefaultSqlSession内部持有底层的Executor以及与数据库物理连接绑定的物理事务管理器。这个物理连接是有状态的。如果将DefaultSqlSession声明为单例并供多线程共享并发调用,多个线程会共享同一个底层物理 Connection 执行读写,这会瞬间引发严重的多线程连接状态错乱、事务越界提交或死锁。因此,SqlSession绝对是线程不安全的,其生命周期必须被严格限制在单次请求/方法调用范围内(用完即丢)。 - Spring 的 ThreadLocal 救赎(
SqlSessionTemplate机制):- 我们在开发中通过 Spring 注入的 Mapper 接口,底层调用的实际上是
SqlSessionTemplate代理类(它实现了 SqlSession 接口)。 SqlSessionTemplate内部采用了动态代理:- 当多线程并发调用 Mapper 接口的方法时,
SqlSessionTemplate代理对象截获请求。 - 它不会无脑使用单例连接,而是通过内部的
SqlSessionHolder(底层基于 ThreadLocal 机制),去当前线程的本地变量中检索是否已经存在与当前线程绑定的物理SqlSession实例。 - 无则新建,有则复用:如果是当前线程第一次调用,它会为当前线程新建一个 SqlSession,并绑定到 ThreadLocal 中,确保这个 SqlSession 只被当前这一个线程在生命周期内独占使用。
- 方法执行完毕后,代理对象会自动清理 ThreadLocal 并关闭 SqlSession。这一设计在完全不改动开发体验的前提下,以高内聚的 ThreadLocal 机制彻底扫清了分布式多线程下的物理连接状态错乱死锁隐患。
- 当多线程并发调用 Mapper 接口的方法时,
- 我们在开发中通过 Spring 注入的 Mapper 接口,底层调用的实际上是
- 成因剖析:MyBatis 的